package jxerializeml;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;
import java.util.Stack;

import jxerializeml.clues.classes.InvokeClassListeners;
import jxerializeml.clues.fields.AllowedMaxValue;
import jxerializeml.clues.fields.AllowedMinValue;
import jxerializeml.clues.fields.AllowedValuesRange;
import jxerializeml.clues.fields.InvokeSetter;
import jxerializeml.clues.fields.RewriteField;
import jxerializeml.clues.fields.SetComment;
import jxerializeml.clues.structures.ClassListener;
import jxerializeml.errors.JXerializeMLError;
import jxerializeml.errors.input.DeserializationError;
import jxerializeml.errors.input.InvalidInputValue;
import jxerializeml.errors.input.SetterNotFound;
import jxerializeml.errors.output.SerializationError;
import jxerializeml.xml.FieldBean;
import jxerializeml.xml.builder.XMLBuilder;
import jxerializeml.xml.reader.XMLReader;
import jxerializeml.xml.reader.XMLReaderListener;
import jxerializeml.xml.transformers.InTransformer;
import jxerializeml.xml.transformers.OutTransformer;

/**
 * Class for serialization and deserialization of objects into the XML document.
 * 
 * @author Tomáš Zíma
 * @param <T>
 *     Type of the (de)serialized object.
 */
public class JXerializeML<T> {
	/**
	 * Serializes object into the XML document.
	 * 
	 * @param object
	 *     Object to be serialized.
	 *     
	 * @param outTransformer
	 *     Transformer which should be used for serialization. You may use any from
	 *     DefaultTransformers.
	 *     
	 * @return
	 *     Generated XML document.
	 *     
	 * @throws JXerializeMLError
	 *     Some error has occurred. Check method "getOriginalException" for
	 *     more details. 
	 */
	public String serialize(Object object, OutTransformer outTransformer) throws JXerializeMLError {
		try {
    		XMLBuilder xmlBuilder = new XMLBuilder(object.getClass().getName(), outTransformer);
    		invokeClassListener(object, ClassListener.PRE_SERIALIZE);
    		
    		new Object() {
    			public final void build(Object object, XMLBuilder xmlBuilder) throws IllegalArgumentException, IllegalAccessException {
    				List<Field> fields = new PolicyManager(object).getSerializableFields();
    				
    				for (Field field : fields) {
    					field.setAccessible(true);
    					
    					xmlBuilder.addField(createFieldBean(field, object));
    
    					if (field.getType().isArray()) {
    						if (field.get(object) != null) {
    							int arrayLength = Array.getLength(field.get(object));
    
    							for (int itemIndex = 0; itemIndex < arrayLength; itemIndex++) {
    								Object		itemObject	= Array.get(field.get(object), itemIndex);
    								FieldBean	fieldBean	= createArrayItemBean(field, object, itemIndex);
    
    								xmlBuilder.enterNode();
    									xmlBuilder.addArrayItem(fieldBean);
    										xmlBuilder.enterNode();
    											if (itemObject != null) {
    												build(itemObject, xmlBuilder);
    											}
    										xmlBuilder.leaveNode();
    								xmlBuilder.leaveNode();
    							}
    						}
    					}
    					else
    					if (!PrimitiveType.isPrimitive(field.getType())) {
    						xmlBuilder.enterNode();
    							if (field.get(object) != null) {
    								build(field.get(object), xmlBuilder);
    							}
    						xmlBuilder.leaveNode();
    					}
    				}
    			}
    		}.build(object, xmlBuilder);
    		
    		invokeClassListener(object, ClassListener.POST_SERIALIZE);
    		return xmlBuilder.getResult();
		} catch (IllegalAccessException | SecurityException err) {
			throw new SerializationError(
				"Internal error: JXerializeML has tried to access an element or proceed"
				+ "some operation, which is not permitted to. This may be also caused by"
				+ "configuration of your security manager.",
				
				err
			);
		} catch (IllegalArgumentException err) {
			throw new SerializationError(
				"Internal error: JXerializeML has tried to proceed an invalid operation"
				+ "(e.g.: reading or setting the field on invalid object).",
				
				err
			);
		}
	}
	
	/**
	 * Deserializes the object.
	 * 
	 * @param xmlObject
	 *     XML document with serialized object to be deserialized.
	 *     
	 * @param inTransformer
	 *     Transformer to be used for deserialization. You may use any from
	 *     DefaultTransformers.
	 *     
	 * @return
	 *     Deserialized object.
	 *     
	 * @throws JXerializeMLError
	 *     Some error has occurred while deserializing. Check method
	 *     "getOriginalException" for more details.
	 */
	public T deserialize(String xmlObject, InTransformer inTransformer) throws JXerializeMLError {
		final class ReaderListener implements XMLReaderListener {
			private Object			deserializedObject	= null;
			private Object			lastObject			= null;
			private Stack<Object>	objectsTrack		= new Stack<>();

			@SuppressWarnings({"rawtypes", "unchecked"})
			@Override
			public void setField(FieldBean fieldBean) throws DeserializationError {
				try {
					Field field = getField(objectsTrack.lastElement(), fieldBean.getName());
					field.setAccessible(true);
					
					String value = fieldBean.getValue();
					
					switch (PrimitiveType.getPrimitiveType(field.getType())) {
						case LONG:			lastObject = Long.valueOf(value);						break;
						case INTEGER:		lastObject = Integer.valueOf(value);					break;
						case SHORT:			lastObject = Short.valueOf(value);						break;
						case BYTE:			lastObject = Byte.valueOf(value);						break;
						
						case DOUBLE:		lastObject = Double.valueOf(value);						break;
						case FLOAT:			lastObject = Float.valueOf(value);						break;
						
						case CHARACTER:		lastObject = Character.valueOf(value.charAt(0));		break;
						case STRING:		lastObject = String.valueOf(value);						break;

						case BOOLEAN:		lastObject = Boolean.valueOf(value);					break;
						
						case NON_PRIMITIVE:
							if (field.getType().isArray()) {
								Class<?> componentType = field.getType().getComponentType();

								if (componentType.isPrimitive()) {
									switch (PrimitiveType.getPrimitiveType(componentType)) {
										case LONG:				lastObject = new long[fieldBean.getLength()];		break;
										case INTEGER:			lastObject = new int[fieldBean.getLength()];		break;
										case SHORT:				lastObject = new short[fieldBean.getLength()];		break;
										case BYTE:				lastObject = new byte[fieldBean.getLength()];		break;
											
										case DOUBLE:			lastObject = new double[fieldBean.getLength()];		break;
										case FLOAT:				lastObject = new float[fieldBean.getLength()];		break;
											
										case CHARACTER:			lastObject = new char[fieldBean.getLength()];		break;
										case STRING:			lastObject = new String[fieldBean.getLength()];		break;
											
										case BOOLEAN:			lastObject = new boolean[fieldBean.getLength()];	break;
										default:
									}
								}
								else {
									lastObject = Array.newInstance(componentType, fieldBean.getLength());
								}
							}
							else {
								if (field.getType().isEnum()) {
									lastObject = Enum.valueOf((Class<? extends Enum>) field.getType(), fieldBean.getValue());
								}
								else {
									lastObject = Class.forName(fieldBean.getClassName()).newInstance();
								}
							}
							break;
					}

					// Values checking
					if (PrimitiveType.isNumeric(field.getType())) {
						AllowedMinValue		allowedMinValue		= field.getAnnotation(AllowedMinValue.class);
						AllowedMaxValue		allowedMaxValue		= field.getAnnotation(AllowedMaxValue.class);
						AllowedValuesRange	allowedValuesRange	= field.getAnnotation(AllowedValuesRange.class);
						
						Double minValue = null;
						Double maxValue = null;
						
						if (allowedMinValue != null) {
							minValue = allowedMinValue.min();
						}
						
						if (allowedMaxValue != null) {
							maxValue = allowedMaxValue.max();
						}
						
						if (allowedValuesRange != null) {
							minValue = allowedValuesRange.min();
							maxValue = allowedValuesRange.max();
						}
						
						double loadedValue = PrimitiveType.getNumericValue(lastObject);
						
						if (minValue != null && loadedValue < minValue) {
							throw new InvalidInputValue(
								"Loaded value (for field " + fieldBean.getName() + ") is too small and doesn't meet"
								+ "criteria given by defined field annotations.",
								
								null
							);
						}
						
						if (maxValue != null && loadedValue > maxValue) {
							throw new InvalidInputValue(
								"Loaded value (for field " + fieldBean.getName() + ") is too big and doesn't meet"
								+ "criteria given by defined field annotations.",
								
								null
							);
						}
					}
					
					if (!invokeSetter(objectsTrack.lastElement(), field, lastObject)) {
						field.set(objectsTrack.lastElement(), lastObject);
					}
				} catch (NoSuchFieldException err) {
					throw new DeserializationError(
						"JXerializeML has tried to set up some field which doesn't exist.",
						err
					);
				} catch (IllegalArgumentException err) {
					throw new DeserializationError(
						"Internal error: JXerializeML has tried to set up some field, but has"
						+ "used an invalid argument.",
						
						err
					);
				} catch (IllegalAccessException | SecurityException err) {
					throw new DeserializationError(
						"JXerializeML has tried to proceed some operation which is not permitted"
						+ "to. This may be caused by configuration of security manager.",
						
						err
					);
				} catch (InstantiationException err) {
					throw new DeserializationError(
						"JXerializeML has tried to create instance of some object, but it has failed."
						+ "This could have several possible reasons, e.g.: non-public constructor of"
						+ "deserialized class.",
						
						err
					);
				} catch (ClassNotFoundException err) {
					throw new DeserializationError(
						"JXerializeML has tried to create instance of some object, or to work with some"
						+ "class in other way, but it has failed, because the given class coudln't be"
						+ "found.",
						
						err
					);
				}
			}
			
			@SuppressWarnings({"rawtypes", "unchecked"})
			@Override
			public void setArrayItem(FieldBean fieldBean) throws DeserializationError {
				try {
					Object	object	= objectsTrack.lastElement();
					int		index	= Integer.valueOf(fieldBean.getName());
					String	value	= fieldBean.getValue();
					
					switch (PrimitiveType.getPrimitiveType(object.getClass().getComponentType())) {
						case LONG:			lastObject = Long.valueOf(value);					break;			
						case INTEGER:		lastObject = Integer.valueOf(value);				break;
						case SHORT:			lastObject = Short.valueOf(value);					break;
						case BYTE:			lastObject = Byte.valueOf(value);					break;
	
						case DOUBLE:		lastObject = Double.valueOf(value);					break;
						case FLOAT:			lastObject = Float.valueOf(value);					break;
							
						case CHARACTER:		lastObject = Character.valueOf(value.charAt(0));	break;
						case STRING:		lastObject = value;									break;
							
						case BOOLEAN:		lastObject = value;									break;
							
						case NON_PRIMITIVE:
							if (object.getClass().getComponentType().isEnum()) {
								lastObject = Enum.valueOf(
									(Class<? extends Enum>) object.getClass().getComponentType(),
									fieldBean.getValue()
								);
							}
							else {
								lastObject = object.getClass().getComponentType().newInstance();
							}
					}
					
					Array.set(object, index, lastObject);
				} catch (InstantiationException err) {
					throw new DeserializationError(
						"JXerializeML has tried to create an instance, but it has failed. It could"
						+ "have various reasons, e.g. non-public constructor.",
						
						err
					);
				} catch (IllegalAccessException | SecurityException err) {
					throw new DeserializationError(
						"JXerializeML has tried to access something what isn't allowed to. Reason may"
						+ "be in your configuration of security manager etc.",
						
						err
					);
				}
			}
			
			@Override
			public void createObject(String className) throws JXerializeMLError {
				try {
					deserializedObject	= ClassLoader.getSystemClassLoader().loadClass(className).newInstance();
					lastObject			= deserializedObject;
					
					invokeClassListener(deserializedObject, ClassListener.PRE_DESERIALIZE);
				} catch (InstantiationException err) {
					throw new DeserializationError(
						"JXerializeML has tried to create an instance of the serialized object, but"
						+ "it has failed. Reason may be for example non-public constructor etc.",
						
						err
					);
				} catch (IllegalAccessException | SecurityException err) {
					throw new DeserializationError(
						"JXerializeML has tried to access an element which is not permitted to. It may"
						+ "be caused by invalid configuration of your security manager.",
						
						err
					);
				} catch (ClassNotFoundException err) {
					throw new DeserializationError(
						"JXerializeML has tried to create and instance of the serialized object, but"
						+ "the defining class couldn't be found.",
						
						err
					);
				} catch (IllegalArgumentException err) {
					throw new DeserializationError(
						"JXerializeML has tried to invoke a class listener, but it failed due to invalid"
						+ "arguments. It may be caused by invalid definition of your listener method. It"
						+ "shouldn't take any parameters.",
						
						err
					);					
				}
			}
			
			@Override
			public void enterNode() {
				objectsTrack.push(lastObject);
			}

			@Override
			public void leaveNode() {
				if (!objectsTrack.isEmpty()) {
					lastObject = objectsTrack.pop();
				}
			}
			
			@SuppressWarnings("unchecked")
			public T getObject() {
				return (T) deserializedObject;
			}
		}
		
		ReaderListener readerListener = new ReaderListener();
		new XMLReader(xmlObject, inTransformer, readerListener).parse(); 
		
        invokeClassListener(readerListener.getObject(), ClassListener.POST_DESERIALIZE);
		
		return readerListener.getObject();
	}
	
	/**
	 * Serializes object and writes output into the file.
	 * 
	 * @param object
	 *     Object to be serialized.
	 *     
	 * @param filename
	 *     Name of the output file.
	 *     
	 * @param outTransformer
	 *     Transformer to be used for serialization.
	 *     
	 * @throws JXerializeMLError
	 *     Some error has occurred during serialization. Check
	 *     "getOriginalException" for more details.
	 *     
	 * @throws IOException
	 *     Any I/O error.
	 */
	public void writeObject(T object, String filename, OutTransformer outTransformer) throws JXerializeMLError, IOException {
		FileOutputStream outputStream = new FileOutputStream(new File(filename));
		
		outputStream.write(serialize(object, outTransformer).getBytes());
		outputStream.close();
	}
	
	/**
	 * Deserializes object from the file.
	 * 
	 * @param filename
	 *     Name of the file which contains object to be deserialized.
	 *     
	 * @param inTransformer
	 *     Transformer which should be used for deserialization.
	 *     
	 * @return
	 *     Deserialized object.
	 *     
	 * @throws IOException
	 *     Any I/O error has occurred.
	 *     
	 * @throws JXerializeMLError
	 *     Some error has occurred during deserialization. Use method
	 *     "getOriginalException" for more details.
	 */
	public T readObject(String filename, InTransformer inTransformer) throws IOException, JXerializeMLError {
		byte[]	encodedContent	= Files.readAllBytes(Paths.get(filename));
		String	fileContent		= StandardCharsets.UTF_8.decode(ByteBuffer.wrap(encodedContent)).toString();

		return deserialize(fileContent, inTransformer);
	}

	/**
	 * Creates field bean from the field.
	 * 
	 * @param field
	 *     Field to be read.
	 *     
	 * @param object
	 *     Object where the field is located.
	 *     
	 * @return
	 *     FieldBean, which contains all informations about the field.
	 *     
	 * @throws IllegalArgumentException
	 * @throws IllegalAccessException
	 */
	protected FieldBean createFieldBean(Field field, Object object) throws IllegalArgumentException, IllegalAccessException {
		field.setAccessible(true);
		FieldBean fieldBean = new FieldBean();
		
		if (field.getAnnotation(RewriteField.class) != null && !field.getAnnotation(RewriteField.class).classType().equals(void.class)) {
			fieldBean.setClassName(field.getAnnotation(RewriteField.class).classType().getCanonicalName());
		}
		else {
			fieldBean.setClassName(field.getType().getCanonicalName());
		}
		
		if (field.getAnnotation(RewriteField.class) != null && !field.getAnnotation(RewriteField.class).fieldName().equals("")) {
			fieldBean.setName(field.getAnnotation(RewriteField.class).fieldName());
		}
		else {
			fieldBean.setName(field.getName());
		}
		
		if (field.getAnnotation(SetComment.class) != null) {
			fieldBean.setComment(field.getAnnotation(SetComment.class).description());
		}
		
		if (PrimitiveType.isPrimitive(field.getType())) {
			fieldBean.setValue(String.valueOf(field.get(object)));
		}
		else
		if (field.getType().isArray()) {
			if (field.get(object) != null) {
				fieldBean.setLength(Array.getLength(field.get(object)));
			}
		}
		else
		if (field.getType().isEnum()) {
			fieldBean.setValue(String.valueOf(field.get(object)));
		}
		
		if (field.get(object) == null) {
			fieldBean.setValue("null");
		}
		
		return fieldBean;
	}
	
	/**
	 * Creates a field bean from the array's item.
	 * 
	 * @param field
	 *     Field which represents the array.
	 *     
	 * @param object
	 *     Object where the field is located.
	 *     
	 * @param index
	 *     Index in the array.
	 *     
	 * @return
	 *     FieldBean, which contains all informations about the
	 *     element.
	 *     
	 * @throws ArrayIndexOutOfBoundsException
	 * @throws IllegalArgumentException
	 * @throws IllegalAccessException
	 */
	protected FieldBean createArrayItemBean(Field field, Object object, int index) throws ArrayIndexOutOfBoundsException, IllegalArgumentException, IllegalAccessException {
		field.setAccessible(true);
		
		FieldBean fieldBean = new FieldBean();
		fieldBean.setName(String.valueOf(index));
		
		if (Array.get(field.get(object), index) != null) {
			if (PrimitiveType.isPrimitive(Array.get(field.get(object), index).getClass()) || field.getType().getComponentType().isEnum()) {
				fieldBean.setValue(String.valueOf(Array.get(field.get(object), index)));
			}
		}
		else {
			fieldBean.setValue("null");
		}
		
		return fieldBean;
	}
	
	/**
	 * Finds field by name (takes care about annotations which rewrites name of the field).
	 * 
	 * @param object
	 *     Object which should contain a field.
	 *     
	 * @param fieldName
	 *     Name of the field (may be rewrited).
	 *     
	 * @return
	 *     Field from the object.
	 *     
	 * @throws NoSuchFieldException
	 */
	protected Field getField(Object object, String fieldName) throws NoSuchFieldException {
		try {
			return object.getClass().getDeclaredField(fieldName);
		} catch (NoSuchFieldException e) {
			Field[] fields = object.getClass().getDeclaredFields();
			
			for (Field field : fields) {
				field.setAccessible(true);
				RewriteField rewriteField = field.getAnnotation(RewriteField.class);
				
				if (rewriteField != null) {
					if (!rewriteField.fieldName().equals("") && rewriteField.fieldName().equals(fieldName)) {
						return field;
					}
				}
			}
			
			throw new NoSuchFieldException();
		}
	}
	
	/**
	 * Invokes class listener (if required by InvokeClassListeners annotation).
	 * 
	 * @param object
	 *     Object to invoke listeners on.
	 *     
	 * @param classListener
	 *     Type of the listener.
	 *     
	 * @throws JXerializeMLError
	 */
	protected void invokeClassListener(Object object, ClassListener classListener) throws JXerializeMLError {
		try {
    		InvokeClassListeners	invokeClassListeners	= object.getClass().getAnnotation(InvokeClassListeners.class);
    		ClassListener[]			definedClassListeners	= invokeClassListeners.methods();
    		
    		for (ClassListener definedClassListener : definedClassListeners) {
    			if (definedClassListener == classListener) {
    				Method method = object.getClass().getDeclaredMethod(definedClassListener.getMethodName());
    				method.setAccessible(true);
    				method.invoke(object);
    			}
    		}
		} catch (IllegalAccessException | SecurityException e) {
			throw new JXerializeMLError(
				"JXerializeML has tried to invoke a class listener, but access wasn't permitted. This"
				+ "may be caused by invalid configuration of the security manager.",
				
				e
			);
		} catch (IllegalArgumentException e) {
			throw new JXerializeMLError(
				"JXerializeML has tried to invoke a class listener, but it has failed due to invalid"
				+ "arguments. This is probably caused by invalid definition of your listener method."
				+ "It shouldn't take any parameter.",
				
				e
			);
		} catch (InvocationTargetException e) {
			throw new JXerializeMLError(
				"JXerializeML has invoked the class listener, but it has thrown an exception. This is"
				+ "problem with the client code.",
				
				e
			);			
		} catch (NoSuchMethodException e) {
			throw new JXerializeMLError(
				"JXerializeML has tried to invoke a class listener, but it couldn't be found.",
				e
			);			
        }
	}
	
	/**
	 * Invokes setter of the field.
	 * 
	 * @param object
	 *     Object which contains the field.
	 *     
	 * @param field
	 *     Field to be set via setter.
	 *     
	 * @param value
	 *     Value to be set.
	 *     
	 * @return
	 *     True if value is set or false if not.
	 *     
	 * @throws SetterNotFound
	 *     Thrown if usage of setter is mandatory but
	 *     setter coudln't be found.
	 */
	protected boolean invokeSetter(Object object, Field field, Object value) throws SetterNotFound {
		InvokeSetter	invokeSetter	= field.getAnnotation(InvokeSetter.class);
		String			methodName		= generateSetterName(field.getName());
		
		if (invokeSetter != null) {
			if (!invokeSetter.methodName().isEmpty()) {
				methodName = invokeSetter.methodName();
			}

			try {
				Method method = object.getClass().getDeclaredMethod(methodName, field.getType());
				method.invoke(object, value);
				return true;
			}
			catch (Exception e) {
				if (invokeSetter.mandatory()) {
					throw new SetterNotFound(
						"JXerializeML has detected that invocation of the setter is mandatory, but"
						+ "the setter couldn't be found.",
						
						null
					);
				}
			}
		}
		
		return false;
	}

	/**
	 * Generates name for a setter.
	 * 
	 * @param fieldName
	 *     Name of the field.
	 *     
	 * @return
	 *     Name of the setter.
	 */
	protected String generateSetterName(String fieldName) {
		return "set" + Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1); 
	}
	
	/**
	 * Generates name for a getter.
	 * 
	 * @param fieldName
	 *     Name of the field.
	 *     
	 * @return
	 *     Name of the getter.
	 */
	protected String generateGetterName(String fieldName) {
		return "set" + Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1);
	}
}
